昨天我們實作新增項目的功能。今天,我們將繼續為家用品管理 App 增加一個功能,那就是編輯現有的項目。這樣,使用者可以在物品資訊發生變動時輕鬆地進行修改,保持清單的準確性。
我們將設計一個編輯項目的頁面(EditItemView),該頁面會包含目前物品的所有資訊,讓使用者可以對名稱、數量、價格、日期等欄位進行修改。此外,我們還會加入「已使用數量」的區塊,讓使用者可以記錄物品的使用情況。
昨天我們更新了 DataManager 的 addItem 方法,今天要來更新 updateItem 方法,讓它能夠接收更多的資訊。
func updateItem(item: Item, name: String, quantity: Int, price: Double, dateAdded: Date, expiryDate: Date?, isUsedUp: Bool, usedQuantity: Int) -> Bool {
item.name = name
item.quantity = Int16(quantity)
item.isUsedUp = isUsedUp
item.dateAdded = dateAdded
item.expiryDate = expiryDate
item.price = price
item.usedQuantity = Int16(usedQuantity)
return saveContext()
}
首先,我們要為 EditItemView 設計一個 ViewModel。這個 ViewModel 將負責處理使用者輸入的資料並與 DataManager 進行互動。
在 EditItemViewModel 中,我們將檢查使用者輸入的每個欄位,確認資料格式正確。如果有任何錯誤,將顯示錯誤訊息給使用者。如果檢查通過,並且成功將資料更新到資料庫,我們會顯示成功訊息,通知使用者,同時返回到首頁,方便使用者進行其他操作。
請新增一個 Swift 檔,並命名為 EditItemViewModel,後加入以下程式碼:
class EditItemViewModel: ObservableObject {
@Published var name: String
@Published var quantity: Int
@Published var price: String
@Published var dateAdded: Date
@Published var expiryDate: Date
@Published var shouldRemindExpiryDate: Bool
@Published var usedQuantity: Int
@Published var showSuccessToast: Bool = false
@Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
private let dataManager: DataManager
private let maxNameLength = 255
private let maxQuantity: Int = 1000
private let maxPrice: Double = 1000000.0
}
和 AddItemViewModel 一樣,先宣告使用到的欄位。因為編輯時我們希望可以讓使用者標記物品的使用狀況,所以會多了 usedQuantity 這個欄位。
和 AddItemViewModel 不一樣的是,我們要在 ViewModel 中建立一個變數 item,為了從首頁將要編輯的項目傳遞到編輯頁面進行編輯,我們需要使用 item 來將項目的資料顯示在畫面上。並且要實作 init() 進行 ViewModel 的初始化。
private let item: Item
init(dataManager: DataManager, item: Item) {
self.dataManager = dataManager
self.item = item
// 初始化各個欄位
self.name = item.name ?? ""
self.quantity = Int(item.quantity)
self.price = String(item.price)
self.dateAdded = item.dateAdded ?? Date()
self.expiryDate = item.expiryDate ?? Date()
self.shouldRemindExpiryDate = item.expiryDate != nil
self.usedQuantity = Int(item.usedQuantity)
}
接下來,我們要實作資料欄位的驗證:
func save() {
if validateAndSave(), let priceValue = Double(price) {
let result = dataManager.updateItem(item: item, name: name, quantity: Int(Int16(quantity)), price: priceValue, dateAdded: dateAdded, expiryDate: shouldRemindExpiryDate ? expiryDate : nil, isUsedUp: quantity == usedQuantity ? true : false, usedQuantity: usedQuantity)
if result {
showSuccessToast = true
} else {
failHandle = (isFail: true, title: "儲存失敗")
}
}
}
func validateAndSave() -> Bool {
// 驗證名稱
guard !name.isEmpty else {
failHandle = (isFail: true, title: "名稱不能為空")
return false
}
guard name.count <= maxNameLength else {
failHandle = (isFail: true, title: "名稱字數不能超過 \(maxNameLength) 個字")
return false
}
// 驗證數量
guard quantity > 0 else {
failHandle = (isFail: true, title: "數量不能小於 1")
return false
}
guard quantity <= maxQuantity else {
failHandle = (isFail: true, title: "數量不能超過 \(maxQuantity)")
return false
}
// 驗證價格
guard let priceValue = Double(price), priceValue >= 0 else {
failHandle = (isFail: true, title: "價格格式錯誤或價格不能為負數")
return false
}
guard priceValue <= maxPrice else {
failHandle = (isFail: true, title: "價格不能超過 \(maxPrice)")
return false
}
// 驗證新增日
guard dateAdded <= Date() else {
failHandle = (isFail: true, title: "新增日期不能大於今天")
return false
}
// 驗證到期日(可為空,但若不為空則需驗證)
if shouldRemindExpiryDate && expiryDate < Date() {
failHandle = (isFail: true, title: "到期日不能小於今天")
return false
}
// 驗證已使用數量
guard usedQuantity >= 0 else {
failHandle = (isFail: true, title: "已使用數量不能小於 0")
return false
}
guard usedQuantity <= quantity else {
failHandle = (isFail: true, title: "已使用數量不能超過 \(quantity)")
return false
}
return true
}
這樣 EditItemViewModel 的部分就完成啦,我把完整的 code 放在這裡,給需要的讀者參考:
class EditItemViewModel: ObservableObject {
@Published var name: String
@Published var quantity: Int
@Published var price: String
@Published var dateAdded: Date
@Published var expiryDate: Date
@Published var shouldRemindExpiryDate: Bool
@Published var usedQuantity: Int
@Published var showSuccessToast: Bool = false
@Published var failHandle: (isFail: Bool, title: String) = (isFail: false, title: "")
private let dataManager: DataManager
private let item: Item
// 資料庫限制的最大值
private let maxNameLength = 255
private let maxQuantity: Int = 1000
private let maxPrice: Double = 1000000.0
init(dataManager: DataManager, item: Item) {
self.dataManager = dataManager
self.item = item
// 初始化各個欄位
self.name = item.name ?? ""
self.quantity = Int(item.quantity)
self.price = String(item.price)
self.dateAdded = item.dateAdded ?? Date()
self.expiryDate = item.expiryDate ?? Date()
self.shouldRemindExpiryDate = item.expiryDate != nil
self.usedQuantity = Int(item.usedQuantity)
}
func save() {
if validateAndSave(), let priceValue = Double(price) {
let result = dataManager.updateItem(item: item, name: name, quantity: Int(Int16(quantity)), price: priceValue, dateAdded: dateAdded, expiryDate: shouldRemindExpiryDate ? expiryDate : nil, isUsedUp: quantity == usedQuantity ? true : false, usedQuantity: usedQuantity)
if result {
showSuccessToast = true
} else {
failHandle = (isFail: true, title: "儲存失敗")
}
}
}
func validateAndSave() -> Bool {
// 驗證名稱
guard !name.isEmpty else {
failHandle = (isFail: true, title: "名稱不能為空")
return false
}
guard name.count <= maxNameLength else {
failHandle = (isFail: true, title: "名稱字數不能超過 \(maxNameLength) 個字")
return false
}
// 驗證數量
guard quantity > 0 else {
failHandle = (isFail: true, title: "數量不能小於 1")
return false
}
guard quantity <= maxQuantity else {
failHandle = (isFail: true, title: "數量不能超過 \(maxQuantity)")
return false
}
// 驗證價格
guard let priceValue = Double(price), priceValue >= 0 else {
failHandle = (isFail: true, title: "價格格式錯誤或價格不能為負數")
return false
}
guard priceValue <= maxPrice else {
failHandle = (isFail: true, title: "價格不能超過 \(maxPrice)")
return false
}
// 驗證新增日
guard dateAdded <= Date() else {
failHandle = (isFail: true, title: "新增日期不能大於今天")
return false
}
// 驗證到期日(可為空,但若不為空則需驗證)
if shouldRemindExpiryDate && expiryDate < Date() {
failHandle = (isFail: true, title: "到期日不能小於今天")
return false
}
return true
}
}
接下來,我們來建立編輯頁的 UI - EditItemView。這個頁面會讓使用者檢視和編輯已經存在的項目資料。我們將加入一個 Form
,裡面包含各種輸入元件,如 TextField
、Stepper
、DatePicker
和 Toggle
,來讓使用者編輯物品的詳細資訊。
我們先新增一個名為 EditItemView 的 Swift 檔,並加入以下程式碼:
import SwiftUI
import AlertToast
struct EditItemView: View {
@ObservedObject var viewModel: EditItemViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
Form {
Section(header: Text("基本資料")) {
HStack {
Text("名稱")
Spacer()
TextField("名稱", text: $viewModel.name)
.multilineTextAlignment(.trailing)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
HStack {
Text("數量")
Spacer()
Stepper(value: $viewModel.quantity, in: 1...100) {
Text("\(viewModel.quantity)")
.bold()
.frame(width: 50, alignment: .trailing)
}
}
HStack {
Text("價格")
Spacer()
TextField("價格", text: $viewModel.price)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
Section(header: Text("日期")) {
HStack {
Text("加入日期")
Spacer()
DatePicker("", selection: $viewModel.dateAdded, displayedComponents: .date)
.labelsHidden()
}
Toggle("提醒到期日", isOn: $viewModel.shouldRemindExpiryDate)
if viewModel.shouldRemindExpiryDate {
HStack {
Text("到期日")
Spacer()
DatePicker("", selection: $viewModel.expiryDate, displayedComponents: .date)
.labelsHidden()
}
}
}
Section(header: Text("已使用數量")) {
HStack {
Text("已使用")
Spacer()
Stepper(value: $viewModel.usedQuantity, in: 0...viewModel.quantity) {
Text("\(viewModel.usedQuantity)")
.bold()
.frame(width: 50, alignment: .trailing)
}
}
}
Button(action: {
viewModel.save()
}) {
Text("儲存")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(8)
}
}
.navigationBarTitle("編輯物品", displayMode: .inline)
.toast(isPresenting: $viewModel.failHandle.isFail, alert: {
AlertToast(type: .error(Color.red), title: viewModel.failHandle.title)
})
.toast(isPresenting: $viewModel.showSuccessToast, alert: {
AlertToast(type: .complete(Color.green), title: "儲存成功")
}, completion: {
dismiss()
})
}
}
#Preview {
let dataManager = DataManager()
let item = Item(context: dataManager.container.viewContext)
item.name = "測試物品"
item.quantity = 5
item.price = 100.0
item.dateAdded = Date()
item.expiryDate = Calendar.current.date(byAdding: .day, value: 30, to: Date())
item.usedQuantity = 2
return EditItemView(viewModel: EditItemViewModel(dataManager: dataManager, item: item))
}
這邊除了 UI 排版以外,基本上就是把 Item 的值帶入到相對應的欄位顯示。
比較快速的方法就是拿 AddItemView 來修改,不過不能 UI 長得一模一樣,因為 AddItemView 得畫面直接帶入資訊,使用者會不知道這個欄位的資料代表什麼,所以還是要做一些變化。
在 SwiftUI 中,@Environment(\.dismiss)
是一個用來關閉當前畫面的環境變數。這個屬性在 iOS 15 及之後的版本中被引入,並用來替代過去常用的 @Environment(\.presentationMode)
。
dismiss 的作用dismiss
是一個關閉當前呈現畫面的簡單方法,無論是透過 NavigationLink
推出的畫面,還是透過 sheet
、popover
等方式呈現的畫面,都可以用 dismiss()
來關閉。相比 presentationMode
,dismiss
提供了一個更加簡單、直覺的方式來管理畫面的消失操作。
如何使用
在 EditItemView 中,我們可以透過 @Environment(\.dismiss)
取得 dismiss
方法,並在需要時直接調用 dismiss()
來關閉當前畫面。例如,當使用者成功編輯或新增項目後,我們可以在顯示成功提示後自動關閉畫面,返回到上一層。
@Environment(\.dismiss) private var dismiss
...
.toast(isPresenting: $viewModel.showSuccessToast, alert: {
AlertToast(type: .complete(Color.green), title: "儲存成功")
}, completion: {
dismiss()
})
結合 AlertToast 使用
在我們的範例中,當使用者成功完成某項操作(如儲存資料)後,showSuccessToast 會觸發顯示一個成功提示。當這個提示消失後,我們會自動使用 dismiss()
,這樣可以讓使用者無縫地返回到上一個頁面。
為什麼選擇 dismiss
相比 presentationMode.wrappedValue.dismiss()
,dismiss
更加簡潔且易於理解,特別是在簡單的畫面層級控制中。使用 dismiss
可以讓程式更清晰,並避免不必要的錯誤。
透過這種設計,我們能夠提供更好的使用者體驗,讓 App 的操作更流暢自然。
建立好編輯頁之後,我們要在首頁編寫跳轉到編輯頁的程式,我們來修改一下 List 的地方:
List {
ForEach(viewModel.items) { item in
NavigationLink(destination: EditItemView(viewModel: EditItemViewModel(dataManager: viewModel.dataManager, item: item))) {
HStack {
VStack(alignment: .leading) {
Text(item.name)
if let expiryDate = item.expiryDate {
Text("到期日\(expiryDate)")
.font(.subheadline).foregroundColor(.gray)
}
}
Spacer()
Text("數量: \(item.quantity)").font(.subheadline)
}
}
}
.onDelete(perform: viewModel.deleteItems)
}
如果 viewModel.dataManager 這裡出錯的話,可以去 ItemViewModel 把宣告 dataManager 時設定的 private 移除就可以囉!
我們今天順利完成了家用品管理 App 的編輯功能。雖然這些操作和新增項目的步驟類似,但透過再一次的實作,加深了我們對這些概念的理解。明天我們將繼續為家用品管理 App 增加更多功能。明天見!